Skip to content

gc: close the strhandle, derived-mask and new.target unrooted hazards in the native lowering (#7664) - #7667

Merged
proggeramlug merged 2 commits into
mainfrom
gc/7664-native-lowering-unrooted-hazards
Aug 9, 2026
Merged

gc: close the strhandle, derived-mask and new.target unrooted hazards in the native lowering (#7664)#7667
proggeramlug merged 2 commits into
mainfrom
gc/7664-native-lowering-unrooted-hazards

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes the last piece of #7480 and docs/engine-plan.md item 6: element
Ptr<Shape> for object-literal element types
.

Measured

Pinned quiet mini, release, 7 interleaved rounds, 200k elements x 50 sweeps,
runtime-derived bounds, checksums equal in every cell, rustc/cargo at zero
and 94.6–94.9% idle before and after.

kernel perry before perry after node bun
keep: {v, w}[]#7480's own kernel 408 ms 12 ms 12 ms 12 ms
keep: Node[] — what #7612 covered 13 ms 13 ms 56 ms 12 ms
region-local {v, w}[] (#7034 §3, E1–E5) 15 ms 14 ms 12 ms

34x on the object-literal arm, to parity with both engines. The
named-class arm is unchanged, and so is the region-local arm — that one was
already covered by collectors/ptr_shape_elements.rs, which is why this
change extends the versioned-loop consumer (the parameter/global case) rather
than that pass.

Both arms link byte-identical libperry_runtime.a / libperry_stdlib.a
(sha256 equal), so this is a codegen-only change and the meaningful control is
the emitted IR, below.

What changed

stmt/element_shape_loop.rs::element_class_name resolved Array(Named(C))
only. It now also resolves a declared object type to the __AnonShape_<hash>
class its literals allocate, by matching the declared property order against
the module's anon shapes. The hash cannot be recomputed — mint_anon_shape_class
keys it on the literal's inferred value types ({v: 1} tags i, not n)
while the annotation says number — so the class is found, not derived.
Ambiguity declines rather than guessing, because ctx.classes is a
HashMap and "first match wins" would make the emitted code depend on
iteration order.

receiver_class_name is not widened. That is the #6377 blast radius #7612
deliberately refused; instead the clone is made self-contained. Its
ElementShapeLoopFact already carried the class name and packed slot index, so
the three sites that would otherwise re-derive the class from the receiver now
go through one predicate,
expr::element_shape_loop_fact_for_property_get:

  • lower_raw_f64_class_field_get_for_number_context — the interception moved
    above the receiver_class_name gate;
  • type_analysis::is_numeric_expr's PropertyGet arm;
  • expr::binary's arithmetic-operand router (as a disjunct, rather than by
    widening expr_may_return_boxed_value_from_raw_f64_fallback, which would
    have been a lie — this read has no boxed fallback).

All three are scoped to the fast clone: outside one the fact vector is empty,
so keep[j].v anywhere else is byte-for-byte what it was.

The issue's cost model was wrong, and the correction is load-bearing

#7480 records "no out-of-line guard calls, the cost is stacked inline
diamonds". The object-literal arm actually carried three calls per
iteration
, the third being js_dynamic_string_or_number_add: with no
resolvable class the accumulator loses its numeric proof, so + is not an
fadd. The plan called that "a second, separable lever".

It is not separable. The clone is admitted only if it is provably
call-free (LlBlock::contains_gc_unsafe_call counts every non-llvm. call),
so resolving the element class without restoring the numeric proof emits the
clone, fails the call-free test, branches unconditionally to the slow arm and
buys exactly zero at a cost in code size. Both had to land together, and the
numeric claim inside the clone is stronger than the annotation it replaces:
the residual per-element check already proves GC_OBJ_TYPED_LAYOUT_INTACT,
i.e. that the slot holds a raw double.

IR evidence (--trace llvm, release, PERRY_NO_AUTO_OPTIMIZE=1)

sweep, object-literal kernel, before — no clone at all, zero fadd:

js_typed_feedback_observe_property_get, js_typed_feedback_record_guard_pass,
js_dynamic_string_or_number_add, js_object_get_field_by_name_f64 x2,
js_object_get_field_ic_miss, ...           element_shape blocks: 0   fadd: 0

After — the fast clone is gep + load, a three-load residual check, fadd:

for.element_shape_fast.body:  gep/load elem, hdr mask+cmp, field_count cmp,
                              keys_array cmp, one branch
element_shape.load:           gep + load double + fadd double
FAST CLONE calls: []          FAST CLONE fadd: 1

The remaining calls in the function are the preheader's
(js_array_refresh_local_head, js_array_ensure_element_shape) and the slow
clone's, which is unchanged.

Correctness against the #7660 shape

Every new gap case that reads a {v, w}[] crosses MIN_ARRAY_CAPACITY, so the
growth-forwarding stub the #7660 repair exists for is live on this arm too:
callee-built-and-returned, callee-filled-caller-owned, a 17-element prefix, and
a module-global array read from inside a function (the write-back's
module_globals arm). No new preheader or base derivation was added — the
element-shape preheader is the one #7660 fixed, unchanged.

The gap test also pins the hazards specific to this arm:

  • an inline rows.length bound, which the matcher rejects (it is a
    PropertyGet, not an Integer/LocalGet), so a kernel written the obvious
    way gets no clone and must still print the same number;
  • a layout downgrade under the clone, so the residual check's side exit
    re-runs the current iteration and the accumulator turns into a string exactly
    where JS says it does;
  • two anon shapes sharing field names ({v: number, w: number} vs
    {v: string, w: string}), so a mis-resolved shape would cost the clone and
    never the answer;
  • a mixed numeric/string shape the matcher must decline.

test_gap_repsel_element_shape_loop_clone is byte-identical to the Node 26.5.1
oracle on both arms, and an IR census confirms the new sections are live:
sumRow, sumRow$spec_b_i32 and sumGlobalRows gain the clone, where on
main only sumField and main had it.

An existing gate that could not fail

fast_clone_slice in element_shape_loop_tests.rs sliced from the first
substring occurrence of for.element_shape_fast.cond — which is the
br label %… terminator of the fast preheader, four lines above the slow
preheader — and every assertion made against the result is a negative
(!fast.contains(" call "), !fast.contains("js_array_get_f64"), …). So the
IR census that exists to prove the clone is call-free had been vacuous since
#7612
, on the code that then shipped a SIGBUS. It now finds the block
definition and asserts the slice contains the cloned body and its element
load, so it cannot pass on an empty subject again. Same family as #7024/#7025:
the gate ran, its subject did not.

Tests

crates/perry-codegen/src/stmt/element_shape_loop_tests.rs, 7 new (17 total in
the module, all green). One positive — #7480's kernel reaches the clone, the
clone is call-free, and the accumulate is an fadd (the two halves asserted
together, because either alone is inert) — and six sabotage cases: an ambiguous
shape, a shape a field-type tie can break, an optional property, a shape no
literal allocates, a reordered shape, and a read outside the clone that must
stay on the by-name path.

Gates

lint's 22 extracted commands (22 extracted, 22 ran, 0 failed),
cargo fmt --all -- --check, cargo check --all-targets,
cargo test -p perry-codegen --lib and -p perry-runtime --lib
(--no-fail-fast), the 14 native_root_coverage tests, both
gc_root_dominance arms (--moving-only --seeded-violations 40 and
--statepoints --moving-only), and the full gap suite against
test-parity/gap_snapshot.json.

Validation

Release build, Node 26.5.1 (matches .node-version), PERRY_GC_MOVING_LOOP_POLLS=1
on both corpus arms.

  • gc-root-dominance-statepoints — 7 unrooted / 0 stale, 40/40 seeded
    violations caught, empty allowlist honoured. Budget of 7 is exact: the same
    run with --max-unrooted 6 exits 1, so the ratchet still has a referent it
    can hit.
  • gc-root-dominance (shadow arm) — 0 violations over 2458 functions /
    9833 root stores, 40/40 seeded caught. Unchanged.
  • Checker --self-test + all three audits (--audit-alloc-re,
    --audit-poll-capable, --audit-immovable-sources) — pass.
  • 22 lint gates enumerated from test.yml (including
    check_file_size.sh, addr_class_inventory.py, gc_gate_wiring_check.py) —
    0 failed; cargo fmt --all -- --check clean.
  • cargo test -p perry-codegen --lib 748 passed / 0 failed (19 in
    root_reload, 14 native_root_coverage). cargo test -p perry-runtime --lib 1917 passed / 0 failed. cargo check --all-targets clean.
  • Gap suite (506 tests, full run on the release build): 490 pass. Every
    reported failure is an existing test-parity/gap_snapshot.json /
    known_failures.json entry except one, test_gap_zlib_4917_level
    (compile_fail), which fails identically on the branch point — A/B'd with
    a perry-dev build of 7bde3de24 in the same tree. It is the auto-optimize
    relink dropping the zlib feature from its stdlib archive (host-local); with
    PERRY_NO_AUTO_OPTIMIZE=1 the test compiles, runs, and is byte-identical to
    node. Clearing target/perry-auto-* does not change it, so it is not
    staleness either.
    • The harness also reports ten node_fail -> parity_fail status changes
      (enum_forward_ref, backoff_options, cron_cronjob, …). A codegen change
      cannot alter node's exit status, and each is an existing snapshot entry
      whose recorded reason (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, or an npm import
      with no local node_modules) reproduces here — local classification, not a
      regression.
    • One improvement, reported without claiming it:
      test_gap_iterator_helpers_2874: parity_fail -> pass. Not investigated.
  • test_gap_class_expr_identity, the regression the anchor bug caused, is now
    byte-identical to node and back to passing.

The snapshot is deliberately not regenerated in this PR: the only real
delta is host-local.

Summary by CodeRabbit

  • Bug Fixes

    • Improved garbage-collection safety when preserving constructor targets and reloading values derived from global handles.
    • Corrected register-root handling for derived values, preventing stale or unrooted references during native execution.
    • Expanded validation for global values, derived receivers, mutation scenarios, and call-based derivations.
  • Documentation

    • Updated native lowering guidance and recorded the reduction in remaining unrooted-reference hazards from 21 to 7.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR extends root reload analysis to handle string-handle globals and derived values. It adds rooted new.target save and restore across constructor calls, adds Clone implementations for instruction enums, and lowers the native statepoint checker budget from 21 to 7.

Changes

GC rooting and stale-register handling

Layer / File(s) Summary
Derived-value reload recipes
crates/perry-codegen/src/root_reload.rs, crates/perry-codegen/src/inst.rs
The reload pass tracks string-handle globals and pure bit-operation derivations. It clones and materializes complete recipes with fresh registers. Regression coverage covers mutable globals, reassigned roots, masked receivers, non-collecting windows, and call boundaries. Instruction enums now implement Clone.
Rooted new.target preservation
crates/perry-codegen/src/rooting.rs, crates/perry-codegen/src/lower_call/new.rs
New helpers root the previous new.target value and restore it after constructor calls. Local, imported, ancestor, and standalone constructor paths use the helpers.
Root-dominance validation baseline
.github/workflows/gc-root-dominance.yml, changelog.d/7667-native-lowering-unrooted-hazards.md
The documented remaining violations are four PHI-mediated values, two module globals, and one closure capture. The native checker limit is set to 7. The changelog records the covered hazards and validation changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • PerryTS/perry issue 7664 — The PR addresses the native unrooted-hazard reductions described by the issue.
  • PerryTS/perry issue 7210 — The PR addresses remaining unrooted global and staging hazards discussed by the issue.

Possibly related PRs

  • PerryTS/perry#7316 — Both PRs modify stale-operand reload and re-materialization logic.
  • PerryTS/perry#6983 — Both PRs modify constructor lowering for GC-sensitive state.
  • PerryTS/perry#7663 — This PR updates the native statepoint checker budget and extends the related hazard coverage.

Sequence Diagram(s)

sequenceDiagram
  participant root_reload
  participant Facts
  participant materialize_recipe
  participant stale_instruction
  root_reload->>Facts: analyze reloadable globals and transparent operations
  Facts->>root_reload: return bounded derivation recipes
  root_reload->>materialize_recipe: clone recipe and rename operands
  materialize_recipe->>stale_instruction: insert fresh instructions before stale use
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed but concerns an unrelated object-literal element-shape optimization and omits the required PR sections for this change. Replace the description with the required Summary, Changes, Related issue, Test plan, and Checklist sections describing the native-lowering GC hazard fixes.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main changes to native lowering GC hazards involving string handles, derived masks, and new.target.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/7664-native-lowering-unrooted-hazards

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug force-pushed the gc/7664-native-lowering-unrooted-hazards branch from 70b2434 to e6f874a Compare August 8, 2026 21:55

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/perry-codegen/src/root_reload.rs (1)

1695-1756: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for a multi-step recipe inserted at or above the entry-init boundary.

the_masked_receiver_is_re_derived_not_just_the_load pins the three-instruction recipe. an_insertion_below_the_post_init_splice_does_not_move_it at Line 1438 pins the boundary arithmetic, but only for a single-instruction reload.

The combination is untested: a masked-receiver rewrite at or above entry_init_boundary now advances the boundary by recipe.len() rather than by one (Line 610-617). That arithmetic is what the comment at Line 599-605 calls out as the regression that cost the acceptance arm 30/30 → 0/30, and it changed in this PR.

Two smaller soundness branches are also unpinned:

  • Line 449-451 declines a recipe longer than MAX_RECIPE.
  • Line 430-437 declines a transparent op whose operands come from two different roots.

Both are one-sided rejections, so a regression in either widens the pass silently rather than failing a test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/root_reload.rs` around lines 1695 - 1756, Add
regression coverage alongside the existing masked-receiver tests for a
multi-instruction recipe inserted at or above entry_init_boundary, asserting the
boundary advances by recipe.len() and the full recipe is re-emitted correctly.
Add tests covering rejection of recipes longer than MAX_RECIPE and transparent
operations whose operands originate from different roots, verifying neither is
widened or rewritten. Reuse the existing test helpers and preserve the current
positive masked_receiver behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/gc-root-dominance.yml:
- Line 515: Update the diagnostic text associated with the gated arm near the
max-unrooted configuration to report 7 hazards, matching --max-unrooted 7.
Preserve 21 only in the historical pre-fix count near the earlier baseline.

In `@crates/perry-codegen/src/root_reload.rs`:
- Around line 655-690: Update materialize to return None on any step lacking an
instruction result, abandoning the rewrite without returning partial steps or a
stale register. In the rewrite call site, skip failed materializations and only
rename operands and append steps for successful results. Ensure entry_inserts
and note_entry_block_insertions count only steps actually emitted, using the
same success condition as materialize or the final reloads length.

---

Nitpick comments:
In `@crates/perry-codegen/src/root_reload.rs`:
- Around line 1695-1756: Add regression coverage alongside the existing
masked-receiver tests for a multi-instruction recipe inserted at or above
entry_init_boundary, asserting the boundary advances by recipe.len() and the
full recipe is re-emitted correctly. Add tests covering rejection of recipes
longer than MAX_RECIPE and transparent operations whose operands originate from
different roots, verifying neither is widened or rewritten. Reuse the existing
test helpers and preserve the current positive masked_receiver behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8cc7f228-fdf8-4627-86f7-093cfb6055c5

📥 Commits

Reviewing files that changed from the base of the PR and between cc7ce41 and 70b2434.

📒 Files selected for processing (5)
  • .github/workflows/gc-root-dominance.yml
  • crates/perry-codegen/src/inst.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/src/root_reload.rs
  • crates/perry-codegen/src/rooting.rs

--min-live-bundles 8000 \
--min-relocates 20000 \
--max-unrooted 21 \
--max-unrooted 7 \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the diagnostic baseline from 21 to 7.

This step now gates --max-unrooted 7, but the diagnostic text at Line 528 still says that the gated arm has 21 hazards. Change that text to 7. Keep 21 only as the historical pre-fix count at Line 475.

Proposed text update
-      # ... against the gated arm's 21.
+      # ... against the gated arm's 7.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/gc-root-dominance.yml at line 515, Update the diagnostic
text associated with the gated arm near the max-unrooted configuration to report
7 hazards, matching --max-unrooted 7. Preserve 21 only in the historical pre-fix
count near the earlier baseline.

Comment on lines +655 to +690
/// Re-emit a derivation with fresh registers, returning the instructions in
/// order and the register the last one defines.
///
/// A recipe is self-contained by construction — a load from the root location
/// plus pure bit ops whose every register operand is an earlier step — so the
/// only rewriting needed is step-to-step: each step's operands are renamed to
/// the fresh names of the steps it consumed. The root pointer (`%slot` or
/// `@…handle`) is not a step, is never in the map, and is therefore carried
/// through untouched, which is exactly what makes this a RE-READ.
fn materialize(
recipe: &[LlInst],
counter: &std::rc::Rc<crate::block::RegCounter>,
) -> (Vec<LlInst>, String) {
let mut out: Vec<LlInst> = Vec::with_capacity(recipe.len());
let mut renames: Vec<(String, String)> = Vec::with_capacity(recipe.len());
let mut last = String::new();
for step in recipe {
let mut step = step.clone();
for (old, new) in &renames {
rename_operand(&mut step, old, new);
}
let old_dst = match inst_result(&step) {
Some(d) => d,
// Only loads and pure bit ops become recipe steps, and all three
// define a register. Bail rather than emit a step whose result
// nothing can name.
None => return (Vec::new(), last),
};
let fresh = format!("%r{}", counter.next());
set_inst_result(&mut step, &fresh);
renames.push((old_dst, fresh.trim_start_matches('%').to_string()));
last = fresh;
out.push(step);
}
(out, last)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make the materialize bail abandon the rewrite instead of returning an unusable register.

The bail at Line 681 returns (Vec::new(), last). Two things go wrong at the call site.

  1. out is discarded, so any steps already materialised are lost.
  2. last is returned anyway. On the first step it is String::new(); on a later step it names an instruction that was just discarded.

The caller at Line 638-640 uses that value unconditionally:

let (steps, fresh) = materialize(&r.recipe, &counter);
rename_operand(&mut insts[insn], &r.from, fresh.trim_start_matches('%'));

The stale operand is renamed to % or to an undefined register, and no defining instruction is inserted. The emitted IR does not verify.

The same bail also breaks the entry-boundary count at Line 610-617. entry_inserts sums r.recipe.len(), but a bailed rewrite inserts zero instructions. note_entry_block_insertions then advances entry_init_boundary past the real insertion count — the over-count failure the comment at Line 599-605 and the test at Line 1437 describe.

Today's admission rules make the bail hard to reach, because raw_facts and inst_result parse the same LHS shape. The bail exists as a guard, so it should fail safe.

🛠️ Proposed fix: return `Option` and skip the rewrite on failure
 fn materialize(
     recipe: &[LlInst],
     counter: &std::rc::Rc<crate::block::RegCounter>,
-) -> (Vec<LlInst>, String) {
+) -> Option<(Vec<LlInst>, String)> {
     let mut out: Vec<LlInst> = Vec::with_capacity(recipe.len());
     let mut renames: Vec<(String, String)> = Vec::with_capacity(recipe.len());
     let mut last = String::new();
     for step in recipe {
         let mut step = step.clone();
         for (old, new) in &renames {
             rename_operand(&mut step, old, new);
         }
-        let old_dst = match inst_result(&step) {
-            Some(d) => d,
-            // Only loads and pure bit ops become recipe steps, and all three
-            // define a register. Bail rather than emit a step whose result
-            // nothing can name.
-            None => return (Vec::new(), last),
-        };
+        // Only loads and pure bit ops become recipe steps, and all three
+        // define a register. Abandon the whole rewrite rather than emit a
+        // step whose result nothing can name — a partial recipe would leave
+        // the stale operand renamed to an undefined register.
+        let old_dst = inst_result(&step)?;
         let fresh = format!("%r{}", counter.next());
         set_inst_result(&mut step, &fresh);
         renames.push((old_dst, fresh.trim_start_matches('%').to_string()));
         last = fresh;
         out.push(step);
     }
-    (out, last)
+    Some((out, last))
 }

Then skip the rewrite at the call site and count only what is emitted:

// lines 636-641
let mut reloads: Vec<LlInst> = Vec::new();
for r in &rewrites[i..j] {
    let Some((steps, fresh)) = materialize(&r.recipe, &counter) else {
        continue;
    };
    rename_operand(&mut insts[insn], &r.from, fresh.trim_start_matches('%'));
    reloads.extend(steps);
}

entry_inserts is computed before this loop, so also derive it from the same predicate materialize uses, or move the boundary note after the loop and count reloads.len() for block-0 insertions at or above the boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/root_reload.rs` around lines 655 - 690, Update
materialize to return None on any step lacking an instruction result, abandoning
the rewrite without returning partial steps or a stale register. In the rewrite
call site, skip failed materializations and only rename operands and append
steps for successful results. Ensure entry_inserts and
note_entry_block_insertions count only steps actually emitted, using the same
success condition as materialize or the final reloads length.

@proggeramlug
proggeramlug force-pushed the gc/7664-native-lowering-unrooted-hazards branch from e6f874a to ceab0f0 Compare August 9, 2026 00:13

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/perry-codegen/src/root_reload.rs (1)

1863-1890: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider two more cases: the MAX_RECIPE bound and two same-root operands.

Two changed behaviours have no coverage.

  1. MAX_RECIPE at Line 236 and the recipe.len() > MAX_RECIPE rejection at Line 449 are untested. A chain of nine transparent steps must be declined. An off-by-one in that bound would ship silently.
  2. The comment at Line 600-604 describes one instruction reading two values of the SAME root, and seen_here at Line 605 exists for it. The existing both_stale_operands_of_one_instruction_are_reloaded test uses two different slots, so the same-root path is not exercised. A fixture that passes both the load and the mask of one slot to js_object_set_field_by_name would cover it.

I can draft both tests if that is useful.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/root_reload.rs` around lines 1863 - 1890, Add
coverage for the MAX_RECIPE limit by constructing a chain of nine transparent
derivation steps and asserting apply_to_function declines it, including the
recipe.len() > MAX_RECIPE rejection path. Add a same-root fixture alongside
both_stale_operands_of_one_instruction_are_reloaded where
js_object_set_field_by_name receives both the loaded value and mask derived from
the same slot, exercising seen_here and asserting both operands are handled
correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/root_reload.rs`:
- Around line 466-468: Update the size guard in the root reload construction
around the fixpoint-derived values so it uses the root-load count rather than
values.len(). Preserve the existing MAX_BLOCK_LOAD_PRODUCT threshold and
saturating multiplication, while ensuring the guard reflects the grouping cost
described near the grouping logic.
- Around line 808-814: Update the LlInst::Load handling in the reload-root
detection path to record load_of only for non-volatile, non-atomic loads,
matching raw_facts. Bring LoadFlavor into scope if needed and gate the existing
reloadable_ptr logic on the plain load flavor, while preserving register and use
tracking for all loads.

---

Nitpick comments:
In `@crates/perry-codegen/src/root_reload.rs`:
- Around line 1863-1890: Add coverage for the MAX_RECIPE limit by constructing a
chain of nine transparent derivation steps and asserting apply_to_function
declines it, including the recipe.len() > MAX_RECIPE rejection path. Add a
same-root fixture alongside both_stale_operands_of_one_instruction_are_reloaded
where js_object_set_field_by_name receives both the loaded value and mask
derived from the same slot, exercising seen_here and asserting both operands are
handled correctly.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6f40a1fe-a755-4c78-b629-bb8292978783

📥 Commits

Reviewing files that changed from the base of the PR and between e6f874a and ceab0f0.

📒 Files selected for processing (3)
  • changelog.d/7667-native-lowering-unrooted-hazards.md
  • crates/perry-codegen/src/root_reload.rs
  • crates/perry-codegen/src/rooting.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-codegen/src/rooting.rs

Comment on lines +466 to 468
if blocks.len().saturating_mul(values.len()) > MAX_BLOCK_LOAD_PRODUCT {
return 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

The size guard now counts derived values, so it bails earlier than before.

values previously held only root loads. It now also holds every derived value admitted by the fixpoint, so blocks.len() * values.len() can exceed MAX_BLOCK_LOAD_PRODUCT on functions that passed the guard before this change. Those functions lose the slot reloads they used to get, which is a silent coverage regression rather than a cost saving.

The comment at Line 519 states that grouping by root load puts the cost back at O(blocks × loads). The reachability walk runs once per group, and the group count equals the root-load count, so the guard can use that number instead of values.len().

♻️ Proposed change: measure the guard against the root-load count
-    if blocks.len().saturating_mul(values.len()) > MAX_BLOCK_LOAD_PRODUCT {
+    // The walk below runs once per ROOT LOAD, not once per reloadable value,
+    // so the cost bound is stated in root loads. Counting derived values here
+    // would decline functions the pre-#7664 pass handled.
+    let root_loads = values
+        .iter()
+        .filter(|v| v.recipe.len() == 1)
+        .count();
+    if blocks.len().saturating_mul(root_loads) > MAX_BLOCK_LOAD_PRODUCT {
         return 0;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if blocks.len().saturating_mul(values.len()) > MAX_BLOCK_LOAD_PRODUCT {
return 0;
}
// The walk below runs once per ROOT LOAD, not once per reloadable value,
// so the cost bound is stated in root loads. Counting derived values here
// would decline functions the pre-#7664 pass handled.
let root_loads = values
.iter()
.filter(|v| v.recipe.len() == 1)
.count();
if blocks.len().saturating_mul(root_loads) > MAX_BLOCK_LOAD_PRODUCT {
return 0;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/root_reload.rs` around lines 466 - 468, Update the
size guard in the root reload construction around the fixpoint-derived values so
it uses the root-load count rather than values.len(). Preserve the existing
MAX_BLOCK_LOAD_PRODUCT threshold and saturating multiplication, while ensuring
the guard reflects the grouping cost described near the grouping logic.

Comment on lines 808 to 814
LlInst::Load { dst, ty, ptr, .. } => {
result = reg(dst);
use_op(&mut uses, ptr);
if let (Some(d), Some(p)) = (reg(dst), reg(ptr)) {
if slots.contains(&p) {
load_of = Some((d, *ty, p));
}
if let (Some(d), Some(p)) = (reg(dst), reloadable_ptr(ptr, slots)) {
load_of = Some((d, *ty, p));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exclude volatile and atomic loads here, as the raw parser does.

raw_facts at Line 918 requires !rhs.contains("volatile") && !rhs.contains("atomic") before it records load_of. This arm applies no flavor check, so a LoadFlavor::Volatile or LoadFlavor::AtomicSeqCst load becomes a reload root and is re-executed at every stale use. Re-executing a volatile load duplicates an observable operation.

No current lowering emits a volatile load of a shadow slot or a handle global, so this is latent. Close it here so the two parsers state the same rule.

🛡️ Proposed fix: gate on the plain flavors
-        LlInst::Load { dst, ty, ptr, .. } => {
+        LlInst::Load {
+            dst,
+            ty,
+            ptr,
+            flavor,
+        } => {
             result = reg(dst);
             use_op(&mut uses, ptr);
-            if let (Some(d), Some(p)) = (reg(dst), reloadable_ptr(ptr, slots)) {
-                load_of = Some((d, *ty, p));
-            }
+            // Same exclusion the Raw parser states: re-executing a volatile or
+            // atomic load would duplicate an observable operation.
+            let re_readable = !matches!(
+                flavor,
+                LoadFlavor::Volatile | LoadFlavor::AtomicSeqCst(_)
+            );
+            if re_readable {
+                if let (Some(d), Some(p)) = (reg(dst), reloadable_ptr(ptr, slots)) {
+                    load_of = Some((d, *ty, p));
+                }
+            }
         }

LoadFlavor needs to be in scope; import it alongside LlInst if it is not already.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
LlInst::Load { dst, ty, ptr, .. } => {
result = reg(dst);
use_op(&mut uses, ptr);
if let (Some(d), Some(p)) = (reg(dst), reg(ptr)) {
if slots.contains(&p) {
load_of = Some((d, *ty, p));
}
if let (Some(d), Some(p)) = (reg(dst), reloadable_ptr(ptr, slots)) {
load_of = Some((d, *ty, p));
}
}
LlInst::Load {
dst,
ty,
ptr,
flavor,
} => {
result = reg(dst);
use_op(&mut uses, ptr);
// Same exclusion the Raw parser states: re-executing a volatile or
// atomic load would duplicate an observable operation.
let re_readable = !matches!(
flavor,
LoadFlavor::Volatile | LoadFlavor::AtomicSeqCst(_)
);
if re_readable {
if let (Some(d), Some(p)) = (reg(dst), reloadable_ptr(ptr, slots)) {
load_of = Some((d, *ty, p));
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/root_reload.rs` around lines 808 - 814, Update the
LlInst::Load handling in the reload-root detection path to record load_of only
for non-volatile, non-atomic loads, matching raw_facts. Bring LoadFlavor into
scope if needed and gate the existing reloadable_ptr logic on the plain load
flavor, while preserving register and use tracking for all loads.

Ralph Küpper added 2 commits August 9, 2026 04:44
…#7664)

`gc-root-dominance-statepoints`' `--max-unrooted` ratchet goes 21 -> 7.

#7663 pointed the root-dominance rule at the NATIVE root lowering -- the one
that ships since #7370 -- and reported 21 `unrooted` hazards. Fourteen were
shapes `root_reload.rs` looked straight through, because its rule is stated
over the load's own register and in both shapes the value at risk lives
somewhere else.

  1. The root is a GLOBAL, not an alloca (10 hits). A string literal lowers to
     `load double, ptr @<mod>_.str.N.handle`; the handle global is a registered
     root, so the string is never swept, and an evacuating cycle REWRITES the
     global while a register loaded beforehand keeps the pre-move address.
     #7240's shape, whose fix covered call operands only.

  2. The stale register is DERIVED from the load (3 of 7 unmasked receivers).
     `this.count++` holds the unmasked receiver across the property GET; the
     load's only use is the bitcast ABOVE the collecting call, so the window
     was empty and the function took zero reloads.

  3. `new.target`'s saved previous value (1 hit). `new.rs` saved
     `js_new_target_get()` in a bare register across the whole constructor
     body; the cell is a registered mutable root, so the restore publishes a
     pre-move address back INTO a root the collector scans. #7226's
     `prev_this` bug for `new.target`.

The window is anchored at the ROOT LOAD, not at the derived value. Anchoring at
the derivation looks more precise and is wrong: `main`'s class-object read has
the scope-end shadow-slot clear landing between the load and the mask, so a walk
starting at the mask never sees it and re-read a slot the program had just
nulled -- `(makeAnon(77) as any).v` became `undefined`. Caught by an A/B against
the branch point on `test_gap_class_expr_identity`, not by the dominance
checker, which cannot see a value-correctness bug.

The reload rule is restated over the value's derivation rather than its
register: for a value read out of a collector-rewritten location -- a shadow
slot or a string-handle global -- and any value derived from it by pure bit
ops, every use a collection point can reach re-materialises the whole
derivation. A recipe is extended only through ops that are pure functions of
their operands and whose every register operand is already in the same single
root's recipe, which makes it self-contained and materialisable anywhere.
Grouping by root load also puts the cost back at O(blocks x loads).

`new.target` gets `new_target_save`/`new_target_restore` in `crate::rooting`,
structurally `implicit_this_save`/`implicit_this_restore`. Re-reading the cell
would be the wrong repair: `js_new_target_set` has already overwritten it.

Measured on `Counter__increment`: before, all three statepoints carried an
EMPTY live set, so the receiver was marked by nothing; after, each carries a
"gc-live" bundle and a `gc.relocate`, and the SET reads a mask re-derived from
the relocated pointer plus a fresh load of the handle global.

Remaining 7, each its own slice: 4 unmasked are phi-mediated (the reload has to
go in the predecessor, on the edge); 2 `@perry_global_*` are module-level
variables the program assigns, so they need rooting rather than reloading
(pinned by `a_module_global_is_not_a_reload_source`); 1 capture read. #7664
stays open as the budget's referent.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1382. Ratchet 21 → 7.

The root cause is the finding. All 14 fixes trace to one thing: root_reload.rs stated its rule over the load's own register, while the value at risk lived elsewhere — in a global the collector rewrites, or in a register derived from the load. Restating it over the value's derivation, extended only through pure bit ops whose every register operand is already in the same root's recipe, is a real repair rather than fourteen patches.

The strhandle group is the one that justifies the whole --statepoints mode: load double, ptr @<mod>_.str.N.handle is a registered root that evacuation rewrites, so a register loaded beforehand is stale. #7240 fixed this shape for call operands and never reached the ~194 sites loading a handle global directly — and the shadow-mode gate could not see it because the native lowering is what ships.

IR evidence checked: Counter__increment before had all three statepoints carrying an empty live set — the receiver in no bundle, so nothing marked or rewrote it. After, each carries a "gc-live" bundle and a gc.relocate, and the SET reads a mask re-derived from the relocated pointer. That the re-derivation is what puts the receiver in the bundle is the mechanism, not a side effect.

The regression you introduced, caught, and pinned

Anchoring each derived value's window at its own definition was wrong: main's class-object read has the scope-end shadow-slot clear landing between the load and the mask, so the walk re-read a nulled slot and (makeAnon(77) as any).v returned undefined.

Two things about that are worth more than the fix. You found it by A/B against the branch point, not by the dominance checker — and correctly noted the checker cannot find it, because it sees rooting, not value-correctness. And you pinned it with the controlled twin of the positive case: same frame plus one store, verdict flips 1 → 0. I verified the shipped version myself: 77 5 on both arms and under zeal, matching node.

Both corrections accepted

The issue's shape-1 heading says 9 strhandle and its own list has 10 — the census is 10/7/2/1/1, so my brief inherited an off-by-one from the issue. And my "every fixed hazard's value must appear in the gc-live bundle after" is right for the derived-receiver half but wrong by design for strhandle: a handle global is a root the collector rewrites, so the fix is a re-read below the safepoint and the string never enters a live bundle in either arm. Stating that rather than quietly meeting the weaker bar is the difference between a fix and a fix that looks like one.

The remaining 7, each correctly its own slice

4 phi-mediated (no instruction can go above a phi; the reload belongs in the predecessor on the edge — a different insertion model), 2 @perry_global_* (variables the program assigns, so a re-read can observe a later assignment — they need rooting, not reloading), 1 capture. Pinning the global case with a_module_global_is_not_a_reload_source so that widening the predicate is a test failure rather than a silent decision is exactly right, and I ran it: green.

Not allowlisted, #7664 stays open as the budget's referent, and the budget is exact--max-unrooted 6 exits 1.

Gates: 22/22 lint, fmt clean, perry-codegen --lib 748, perry-runtime --lib 1917 across 6 consecutive runs (the intermittent failure I hit on your branch was the third global-sink flake, CLOSURE_PROPS cleared by the GC guards — pre-existing, fixed in #7671, class filed as #7672). Gap 490 pass with the sole non-snapshot failure A/B-identical at the branch point.

@proggeramlug
proggeramlug force-pushed the gc/7664-native-lowering-unrooted-hazards branch from ceab0f0 to 6ae6795 Compare August 9, 2026 02:47
@proggeramlug
proggeramlug merged commit 2ddbc7b into main Aug 9, 2026
@proggeramlug
proggeramlug deleted the gc/7664-native-lowering-unrooted-hazards branch August 9, 2026 02:48
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
#7667 added new_target_save using crate::expr::temp_root while this slice
moved the module to crate::rooting::temp_root. The two PRs were developed in
parallel; the break only appears once both are on the same tree.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
proggeramlug added a commit that referenced this pull request Aug 9, 2026
…#7670)

* refactor(codegen): split the instance allocation out of lower_call/new.rs (#7615)

`new.rs` was 1,988 lines against `scripts/check_file_size.sh`'s 2,000-line
cap, which blocked the Layer 1 rooting migration (#7615 slice 8) — that
migration has to ADD lines to the file, replacing `refresh_rooted_args`
and the `temp_root_scope_*` marker with a `RootedGroup`.

Pure move, no behaviour change: `lower_new_impl_inner`'s field-count
computation and its three-arm object allocation become
`new_alloc::emit_instance_alloc(ctx, class_name, class) -> String`.
`new_site_is_in_loop` moves with them (its only caller is the inline
bump-allocator arm). The boundary is a boundary rather than a cut because
none of the locals the block defines — `field_count`, `cid_str`,
`parent_cid_str`, `n_str`, `packed_keys`, `alloc_field_count` — is read
anywhere below the allocation.

No rooting decision moves with it: everything the extracted block emits
sits ABOVE the instance root, whose push is the caller's next act on the
returned handle.

new.rs 1,988 -> 1,501; new_alloc.rs 531.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* refactor(codegen): split string concat out of lower_string_method.rs (#7615)

`lower_string_method.rs` was 1,957 lines against the 2,000-line cap, and
the Layer 1 rooting migration adds closure scopes to five of its
functions — `with_operands_rooted` and `with_rooted_accumulator` both
re-indent the body they own, which is line growth on a file with 43 lines
of headroom.

Pure move at the boundary the file already had: everything above
`lower_string_self_append` dispatches a `str.<method>(...)` call,
everything from it down lowers `a + b` / `s += x` on strings.
`str_operand_handle_tag_dispatched` becomes `pub(crate)` because three
dispatch arms above still call it.

lower_string_method.rs 1,957 -> 1,368; lower_string_concat.rs 612.

Also lands the first four module migrations of slice 8 (they share the
`expr/binary.rs` import line with the move):

* `expr/binary.rs` — five `lower_operand_pair_rooted` + `temp_root_release`
  pairs collapse into one `lower_rooted_dynamic_binary` helper over
  `with_operands_rooted`.
* `expr/math_simple.rs` — `MapSet` becomes a `RootedGroup` (two operands,
  unequal windows, eight arm-specific re-read points); `MapGet`/`MapHas`
  become `with_operands_rooted`. `Expr::ArrayMap` gains the root it never
  had: the receiver was lowered, the callback was lowered, and only THEN
  was the receiver unboxed — the unbox sat below its own window.
* `expr/static_field_meta.rs` — `ClassExprFresh` becomes a `RootedGroup`
  over the class object plus a nested `with_rooted_accumulator` for the
  `__perry_ctor_caps` snapshot array, which was threaded through a bare
  SSA register.
* `expr/dyn_extern_i18n.rs` — the namespace-object build becomes
  `with_rooted_accumulator`.
* `lower_call/new.rs` — `refresh_rooted_args` and the
  `temp_root_scope_begin`/`_end` marker become one escaping `RootedGroup`;
  the null marker slot is gone with them.

`RootedGroup::adopt_emitted` gains a `protect` flag (the WINDOW, not the
strategy) and `RootedGroup::is_rooted` returns whether a slot exists.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* fix(gc): Layer 1 rooting slice 8 — the raw API becomes unreachable (#7615)

The campaign's terminal condition, made true: `expr/temp_root.rs` is now
`crate::rooting::temp_root`, declared with a PRIVATE `mod temp_root;` and
with every accessor additionally carrying `pub(in crate::rooting)`.

The plan spelled the condition as "`expr/temp_root.rs` going
`pub(in crate::rooting)`", which is not expressible in Rust — `pub(in path)`
requires `path` to be an ancestor module of the item (E0742), and
`crate::rooting` is not an ancestor of `crate::expr::temp_root`. Hence the
move. Both belts are worn because either alone is one keyword from being
undone.

Two items keep `pub(crate)` and are re-exported from `rooting/mod.rs`;
neither is an accessor and neither can be called in the wrong order:
`TempRootPool` (compile-time slot bookkeeping `FnCtx` owns) and
`expr_is_inert_primitive` (the shared "can evaluating this run user code?"
predicate the loop back-edge poll consults).

Fourteen items are DELETED rather than narrowed, because the migration
left them with no caller: `lower_exprs_rooted`, `lower_operand_pair_rooted`,
`any_later_ref_may_trigger_gc`, `RootedOperands::is_rooted`, the whole
`StoreOperandGuard` family and the whole `RootedHandle` family, and
`temp_root_scope_begin`/`_end`. CLAUDE.md's kill-policy: the losing mode
should stop compiling.

Eight modules migrate (seven load-bearing on the committed source, one —
`lower_call/new_alloc.rs` — vacuous and listed anyway so an unlisted
sibling of a listed module cannot become the place a raw push goes):
`expr/binary.rs`, `expr/math_simple.rs`, `expr/static_field_meta.rs`,
`expr/dyn_extern_i18n.rs`, `lower_string_method.rs`,
`lower_string_concat.rs`, `lower_call/new.rs`, `lower_call/new_alloc.rs`.

Nine further files mention the raw API and make no rooting decision, so
they are deliberately NOT listed: `expr/mod.rs` (module declaration and a
field type, both gone with the move), the four `FnCtx` constructors
(`TempRootPool::default()`), `stmt/loops.rs` (one purity predicate),
`loop_purity.rs` (a doc link only), and `root_reload.rs` /
`gc_call_effects.rs` / `runtime_decls/arrays.rs` plus five test files,
whose `js_gc_temp_root_*` occurrences are runtime SYMBOL NAMES.

One live bug fixed: `Expr::ArrayMap` lowered the receiver, lowered the
callback, and only then unboxed the receiver — the unbox sat below its own
window and masked a stale box rather than repairing it (#7280 taxonomy
(c)).

New: a terminal-condition test over `temp_root.rs`'s own source, with its
own sabotage arm.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* test(gc): pin the slice-8 windows, and record two ways the pin was vacuous (#7615)

Four lowering tests over emitted IR, plus the terminal-condition test's
ledger entry and the campaign's close-out in docs/engine-plan.md.

★ Both vacuities were MEASURED by the sabotage arm (restore the pre-fix
`Expr::ArrayMap` lowering, require red), not reasoned about:

1. Slice 7's `assert_operand_survives_the_window` compares the operand
   register's OWN definition line against the window. For `ArrayMap` that
   register is `and i64 %stale, POINTER_MASK` — emitted BELOW the window
   while masking a value loaded above it. A one-level check cannot see
   "the unbox sits below its own window", which is the bug. These tests
   chase the definition chain through pure bit-twiddling to the first
   real producer.

2. An array-typed LOCAL receiver has no window at all: codegen's
   `ptr addrspace(1)` retype pass rematerialises the load from the local's
   own root slot at the use site, so the pre-fix code re-read the receiver
   by accident. The windows that are real — verified by A/B on emitted IR
   against a `main` baseline — are the receivers with no slot to
   rematerialise from: a module global, a class-field read and a closure
   capture. The tests use the field read.

The window is anchored on the LATER OPERAND'S producer rather than on
"the last object allocation above the call", because which helper an
`Expr::Object` lowering reaches for is not this module's property.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* docs(codegen): repoint the TempRootPool doc link after the move (#7615)

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* fix(codegen): repoint new_target_save at the moved temp_root module

#7667 added new_target_save using crate::expr::temp_root while this slice
moved the module to crate::rooting::temp_root. The two PRs were developed in
parallel; the break only appears once both are on the same tree.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* chore: bump version to 0.5.1384

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant